Skip to content

ci: report disguised file content to code scanning - #4056

Open
pbeza wants to merge 5 commits into
mainfrom
security/ci-disguised-content-scan
Open

ci: report disguised file content to code scanning#4056
pbeza wants to merge 5 commits into
mainfrom
security/ci-disguised-content-scan

Conversation

@pbeza

@pbeza pbeza commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes #4037. Supersedes #4038.

Classifies the content of every changed file with magika and reports any file whose bytes are code or an executable while its extension claims an inert asset. That is the implant we actually got: JavaScript named fa-solid-400.woff2, padded with 1700 spaces to sit off-screen in the diff, force-pushed onto an open PR's head branch. Extension-filtering scanners never open such a file, which is why the disguise worked.

One Python script, no shell, no bespoke gating:

  • uv installs magika from the script's own inline metadata, so the pinned version lives in one place
  • findings go out as SARIF to code scanning, so severity, dismissal and merge blocking are ruleset config rather than code
  • the script exits 0 on a finding and 2 only when it could not scan — a base or head commit missing from the clone, or a file magika cannot read. Each has a test, because each otherwise looks clean
  • git diff -z, because core.quotePath quotes bytes above 0x80 and the quoted literal matches no file on disk, silently dropping a homoglyph-named payload

Needs a repo admin

Without the first, this reports but blocks nothing.

  • Add a code_scanning rule to the default ruleset on main (repos/near/mpc/rulesets/2666105): tool: disguised-content, alerts_threshold: errors, security_alerts_threshold: none. It also blocks while analysis runs or if the tool is missing, so a job that never reports cannot look clean — which is why it is used instead of a required status check, since the skipped same-repo-PR run publishes the same check name and GitHub counts a skip as satisfied.
  • Set require_last_push_approval: true in the same ruleset. Currently false: stale reviews are already dismissed on push and 2 approvals are required, but whoever pushed the new head can still supply one of them, which is the vector here.

Deviation from #4037

The issue asks for YARA and magika; this ships magika only, and I think the issue is what should change. All 54 rules in GuardDog's pack carry path_include and none names .rs — coverage is Python (41 rules), JS/TS (41), Go (18), Ruby (10), shell (2). Running them over everything anyway is off-label, which is what forced 18 of 54 to advisory in #4038 plus a 54-line allowlist re-measured on every bump. For this payload the YARA hit was redundant: magika alone reports "content is javascript, name claims a font".

Worth its own issue: this repo depends on 11 third-party Actions and checks none. GuardDog's github-action ecosystem covers that natively, as an addition rather than a replacement.

Nothing in CI inspects the content of files a change adds. A payload named
like an inert asset is skipped by any scanner that selects files by
extension, which is how an implant force-pushed onto the head branch of an
open pull request went unexamined.

Classify every changed file by content with magika and report any file whose
bytes are code or an executable while its extension declares a binary asset.
Findings are emitted as SARIF and uploaded to code scanning, so severity,
per-finding dismissal and merge blocking are configured in a ruleset rather
than hand-maintained here. The script therefore exits 0 on a finding, and
reserves a non-zero exit for being unable to scan at all.

Cannot-scan is treated as failure throughout, because each case otherwise
looks identical to a clean run: a base or head commit missing from the clone
makes the diff empty, magika exits non-zero for a file it could not read, and
a path holding a byte above 0x80 is quoted by git and drops out of the file
list unless the diff is read with -z.

Fork pull requests cap security-events at read, so the upload cannot run for
them; those fail the job directly and annotate the offending lines instead.
Copilot AI lite review requested due to automatic review settings August 4, 2026 16:11
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The scan script can exit with the wrong status (and potentially leave stale/partial SARIF) on output write failures, undermining the “cannot-scan must fail clearly” contract.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Adds a new CI security workflow that classifies the content of every changed file using Magika and reports “executable/code content under inert-asset extensions” as SARIF to GitHub code scanning, so disguised payloads can be surfaced (and optionally blocked) via branch rulesets rather than bespoke status-check gating.

Changes:

  • Introduces a Security Scan workflow that runs on both push and pull_request, uploads SARIF on pushes, and fails fork PRs directly on findings.
  • Adds a pinned Magika CLI fetch/install script for CI plus a local Nix derivation/devshell wiring for parity.
  • Implements the detector (Magika JSONL → SARIF) and a throwaway-repo test suite covering key failure modes and bypass techniques.
File summaries
File Description
nix/magika.nix Adds a pinned, checksummed Magika CLI derivation (prebuilt upstream binaries) for local/devshell use.
flake.nix Exposes magika in the dev shell so the scan script/tests can be run locally.
.github/workflows/security-scan.yml New workflow to run the scan on pushes/PRs and report via SARIF/code scanning.
.github/security-scan/README.md Documents rationale, local usage, merge blocking model, and version pinning.
.github/scripts/tests/test-scan-changed-files.sh Adds regression tests for bypasses and “cannot scan” failure cases.
.github/scripts/scan-changed-files.sh Orchestrates git diff enumeration, Magika invocation, SARIF generation, and auto-exec path warnings.
.github/scripts/find-type-mismatches.py Converts Magika JSONL output into a SARIF run/results for code scanning.
.github/scripts/fetch-scanners.sh CI-only downloader for the pinned Magika CLI release artifact with checksum verification.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread .github/scripts/scan-changed-files.sh Outdated
Comment on lines +30 to +33
write_sarif() {
python3 "$MISMATCH_SCRIPT" > "$workdir/sarif" || die "Could not write SARIF."
mv "$workdir/sarif" "$SARIF_OUT"
}
Comment thread .github/scripts/fetch-scanners.sh Outdated
Comment on lines +44 to +45
"$dest/magika" --version | grep -q "$MAGIKA_VERSION" \
|| { echo "magika is not version $MAGIKA_VERSION" >&2; exit 1; }
@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown

Pull request overview

Adds a content-based scan of every changed file: magika classifies each file by its bytes, and find-type-mismatches.py reports any file whose content is code/executable while its extension declares an inert binary asset (the fa-solid-400.woff2 JavaScript implant shape). Findings are emitted as SARIF and uploaded to code scanning, so severity, false-positive dismissal, and merge blocking all live on the platform rather than in bespoke script logic; the script exits non-zero only when it cannot scan at all. magika is pinned twice — a checksummed release binary in CI, the same release binary via a new nix derivation for the dev shell.

Changes:

  • New .github/workflows/security-scan.yml running on push to all branches, pull_request (fork PRs only, via a job-level guard), and workflow_dispatch.
  • New .github/scripts/{fetch-scanners.sh,scan-changed-files.sh,find-type-mismatches.py} plus a bash test suite that CI runs before the scan.
  • New nix/magika.nix (upstream release binary, autoPatchelfHook on Linux, install-check that actually runs the binary) wired into the dev shell in flake.nix.
  • New .github/security-scan/README.md documenting the detection rule, local usage, the two pin sites, and the fork-PR reporting path.

Reviewed changes

Per-file summary
File Description
.github/workflows/security-scan.yml New workflow: checkout at depth 0, install magika, resolve a base commit, run the scanner tests, scan, then upload SARIF (non-PR) or fail on findings (fork PR)
.github/scripts/fetch-scanners.sh Downloads the pinned magika release tarball, verifies SHA-256, extracts, asserts --version
.github/scripts/scan-changed-files.sh Diffs BASE_SHA/HEAD_SHA with -z, filters to regular files, runs magika, stages the SARIF, warns on auto-executing paths
.github/scripts/find-type-mismatches.py Converts magika JSONL to a SARIF 2.1.0 run; the three-way mismatch conjunction lives here
.github/scripts/tests/test-scan-changed-files.sh Throwaway-git-repo tests: payload (ASCII + homoglyph names), genuine woff2, non-tip commit, missing shas, empty diff, unreadable file, newline in path
.github/security-scan/README.md Explains content-vs-extension rationale, local invocation, gating via ruleset, fork-PR path, dual pinning
nix/magika.nix New derivation from upstream release binaries for the three systems the flake declares
flake.nix Adds magika to the dev shell alongside opengrep

Findings

Blocking (must fix before merge):

  • .github/workflows/security-scan.yml:63-64On same-repo pushes the base is github.event.before, so each run scans only that push's delta. Combined with code scanning's replace-on-upload semantics, any follow-up push erases the alert the payload raised. Concretely: push A adds fa-solid-400.woff2 (JS) → run A diffs before..A, finds it, uploads a run containing the result → alert opens on refs/heads/<branch>. Push B then touches only README.md → run B diffs A..B, finds nothing, and uploads an empty run for the same ref+category+tool → GitHub marks the earlier alert fixed. This is the same mechanism the README relies on at .github/security-scan/README.md:38 ("uploading it is what clears alerts an earlier commit raised") — it just also clears alerts that are still live in the tree. The ruleset rule then sees no open alerts and the merge proceeds. The concurrency block at :13-15 (cancel-in-progress: true, keyed on github.ref) gives a second route to the same outcome: push A and B back-to-back, run A is cancelled before it uploads anything, run B diffs A..B, and the payload is never scanned at all. Both are reachable by an attacker who already controls the pushes, which is this workflow's stated threat model.

    Suggested fix — always resolve the push base to the branch delta, which is already the fallback path, so every push re-scans the whole branch and the uploaded SARIF is always the complete finding set for that ref:

    -          if [ -n "$PR_BASE_SHA" ]; then
    -            base="$PR_BASE_SHA"
    -          elif git cat-file -e "${PUSH_BEFORE:-missing}^{commit}" 2>/dev/null; then
    -            base="$PUSH_BEFORE"
    -          else
    +          if [ -n "$PR_BASE_SHA" ]; then
    +            base="$(git merge-base "$PR_BASE_SHA" HEAD)"
    +          else

    keeping the existing merge-base origin/$DEFAULT_BRANCH HEAD / HEAD~1 body for the push case. PUSH_BEFORE and the git cat-file -e probe then become dead and can go. The extra cost is re-scanning the branch delta on every push, which magika does well inside the current job budget. Worth a test case for it: commit payload, commit an unrelated file, scan the second push's before..HEAD range and assert the finding is still reported — today it is not.

  • .github/scripts/find-type-mismatches.py:57-65 and .github/scripts/tests/test-scan-changed-files.shThe .wasm exemption is asserted only by a comment, and this repo ships 16 committed .wasm files (crates/contract-history/archive/signer-*.wasm, one added per contract release). The third condition spares a genuine .wasm only if magika lists wasm among extensions for the type it detects; if it does not, every contract-history release PR raises an error-level alert on a legitimate artifact and is hard-blocked until an admin dismisses it. wasm, so, dylib, dll, exe, o, a, lib, class, pyc in ASSET_EXTENSIONS (:22) are all executable by nature and carry the same dependency. Please add a case to the test suite that copies one of the archived wasm files in and asserts zero findings — it is three lines next to the existing "genuine woff2" case, and it pins the assumption the comment currently makes.

Non-blocking (nits, follow-ups, suggestions):

  • .github/scripts/scan-changed-files.sh:56git diff BASE HEAD is a two-dot tree comparison. For fork PRs BASE_SHA is pull_request.base.sha (the base branch tip, not the fork point), so everything merged into main since the branch diverged also lands in the changed-file list and gets scanned. Over-inclusive rather than under-inclusive, so not a correctness hole, but it means a fork PR can be failed for a file it never touched. git diff "$BASE_SHA...$HEAD_SHA" — or resolving the merge-base in the workflow, as the blocking suggestion above does — fixes both call sites at once.
  • .github/scripts/find-type-mismatches.py:124-132 — the continue on a non-ok status contradicts the comment directly above it and the PR's "cannot-scan is treated as failure" invariant: the file does drop out of the check, and the run still exits 0 with a clean SARIF. It only fails today because magika happens to exit non-zero for unreadable files, which the shell catches separately. Returning 2 here would make the invariant hold at the layer that observes the condition. Related: nothing verifies that magika returned an entry for every path it was given, so a silently-omitted file is indistinguishable from a clean one — comparing ${#files[@]} against the JSONL line count in scan-changed-files.sh would close that.
  • .github/security-scan/README.md:44-53 — the admin section (and the PR body) should note the fork-PR interaction with the ruleset rule. Fork PRs never upload SARIF (security-scan.yml:93), so a code scanning rule listing disguised-content as a required tool has no analysis to evaluate for them. If that resolves to "not configured" rather than "satisfied", fork PRs become permanently unmergeable. Worth confirming against a test fork before the rule goes on the ruleset. The same question applies to same-repo PRs, where the analysis is attached to refs/heads/<branch> by a push event rather than to the PR ref — the gating story depends on GitHub surfacing branch analyses to the PR's code scanning rule.
  • .github/scripts/fetch-scanners.sh:22 / nix/magika.nix:12 — the version and digest are duplicated across the two pin sites (they do agree today: sha256-a0wQEMhNH08GIFzO9Fl/FpC813RPRthB7uJkJrwQBIU= decodes to the hex in the shell script). The README says both must move together, but nothing enforces it, and a drift is silent. A two-line grep-and-compare in the test script would make the constraint self-checking.
  • .github/scripts/tests/test-scan-changed-files.sh:139-144 — the unreadable-file case depends on chmod 000 actually denying reads, which it does not for root. In a root container the case reports FAIL rather than skipping, unlike the newline case just below it, which does guard itself. Consider the same if-guard shape.
  • .github/scripts/tests/test-scan-changed-files.sh:64scan_in sends both streams to /dev/null, so a failing case prints only "expected 1, got 0" with no way to tell whether magika, python, or git was the cause. Capturing to a file and dumping it on failure would make CI failures actionable.
  • .github/scripts/tests/test-scan-changed-files.sh:62 — the mktemp SARIF files are never removed, unlike the repos, which are rm -rf'd.

⚠️ Issues found

pbeza added 4 commits August 4, 2026 18:37
magika ships a Python API, so classifying files no longer needs a CLI binary,
JSONL parsing, or exit-code juggling across three shell scripts. Range
resolution moves out of inline workflow bash into the scanner, and uv installs
the pinned dependency from the script's own inline metadata, which removes the
fetch script and the nix derivation along with their duplicated version.

The workflow is now only `uses:` steps and single-command `run:` steps.

A path with a newline no longer has to be refused: paths are passed as
arguments rather than through a newline-delimited list.
permissions: {} at the top level, granted per job, matching astral-sh/uv and
apache/iceberg-rust. Records the conventions a second tool has to follow,
notably a SARIF category unique per tool, since code scanning keeps one
analysis per category.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: scan changed files for malicious code and disguised content

3 participants